@brett_lamy/docstream-editor 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,6 +13,9 @@ TipTap editor components for Docstream GitBook-style markdown documents.
13
13
  - Code block syntax highlighting through [`gpu-lexer`](https://gpu-lexer.vercel.app/) (language-agnostic, WebGPU; plain text where WebGPU is unavailable).
14
14
  - Table, task list, heading, quote, and inline formatting support.
15
15
  - Preserves Docstream/GitBook block semantics when converting back to markdown.
16
+ - Copy a docs page (docstream's "Copy page") and paste it in: the editor shows the same page and
17
+ serializes it back byte-for-byte — demos with their inline files, install command boxes, titled
18
+ tab sets and all.
16
19
  - Preserves mounted source-file/export provenance and can write edits to the real file through Vite.
17
20
 
18
21
  ## Installation
@@ -80,6 +83,10 @@ export interface GitbookEditorProps {
80
83
  onAttachmentAdd?: (attachment: EditorAttachment & { file: File }) => void
81
84
  onAttachmentRemove?: (ids: string[]) => void
82
85
  onAttachmentOpen?: (attachment: EditorAttachment) => void
86
+ demoResolver?: DemoResolver
87
+ demoRuntime?: InlineDemoRuntime
88
+ demoDependencies?: Record<string, string>
89
+ markdownPaste?: boolean
83
90
  }
84
91
  ```
85
92
 
@@ -89,6 +96,14 @@ export interface GitbookEditorProps {
89
96
  return `true` when the application handled the event.
90
97
  - `imagePaste`, `attachments`, `onAttachmentAdd`, `onAttachmentRemove`, `onAttachmentOpen`:
91
98
  how pasted/dropped images are handled — see [Pasted and dropped images](#pasted-and-dropped-images).
99
+ - `demoResolver`: resolves `{% demo src %}` ids so demo blocks show a live, read-only
100
+ preview — see [Demo blocks](#demo-blocks).
101
+ - `demoRuntime` / `demoDependencies`: run demos that carry their files inline and have no
102
+ resolver behind them — the same options as Docstream's `DocRenderOptions`.
103
+ - `markdownPaste` (default `true`): plain-text Markdown pasted into the editor that contains
104
+ blocks (a "Copy page" export, `{% … %}` tags, fences, lists, headings …) is parsed into
105
+ editor blocks instead of pasted as literal text. Plain prose pastes as usual; HTML clipboards
106
+ keep TipTap's own handling (except VS Code's, which is treated as plain text).
92
107
 
93
108
  The editor tracks the last markdown it emitted so normal controlled updates do not continuously reset the TipTap document. Passing a different external `markdown` value replaces the editor content.
94
109
 
@@ -98,18 +113,23 @@ The editor supports common ProseMirror/TipTap content plus GitBook-flavored bloc
98
113
 
99
114
  - Headings
100
115
  - Paragraphs
101
- - Bold, italic, strike, inline code, and links
116
+ - Bold, italic, strike, inline code, and links (a link inside emphasis, `**[x](url)**`, stays
117
+ distinct from emphasis inside a link, `[**x**](url)`)
102
118
  - Bullet, ordered, and task lists
103
119
  - Blockquotes
104
120
  - Code blocks
105
121
  - Tables
106
122
  - Hints
107
- - Tabs
123
+ - Tabs (including `{% tabs sync="key" %}` sync groups, and titled sets
124
+ `{% tabs title="Installation" level="3" %}` rendered as a section heading with the switch on
125
+ its row — click the heading icon in the tab strip to add a title)
126
+ - Package-manager command boxes (`{% command %}npm install x{% endcommand %}`, `/install command`)
108
127
  - Expandables
109
128
  - Steppers
110
129
  - Embeds
111
130
  - Content references
112
131
  - Component and Storybook source references
132
+ - Live demos (`{% demo %}`), resolver-backed or carrying their files inline
113
133
  - Columns
114
134
  - Figures and images
115
135
  - OpenAPI operation placeholders
@@ -201,6 +221,45 @@ provenance, and refreshes the live component/story preview. Configure the mount
201
221
  with `docstreamSources()` from `@brett_lamy/docstream/vite` as shown in the
202
222
  Docstream README.
203
223
 
224
+ ## Demo blocks
225
+
226
+ `{% demo src="<page>/<example>" %}` becomes a `gbDemo` atom node. Every tag
227
+ attribute — `src`, `title`, `description`, `height`, `layout`, `variants`,
228
+ `viewport`, `entry` — is kept on the node, and so are the files the block form
229
+ carries inline (the titled fences between `{% demo … %}` and `{% enddemo %}`, as
230
+ `files: { path, content, language? }[]`), so the block serializes back exactly as
231
+ written. Docstream's "Copy page" writes every demo this way.
232
+
233
+ The node shows a compact header with editable `src` and `title` inputs (insert
234
+ one with `/demo`) and a **Files** toggle that opens a small file editor: a tab
235
+ per file (rename it in place, mark it the entry, remove it), a code area, and
236
+ `+` to add a file. Removing every file gives the one-line tag form again.
237
+
238
+ Under the header, Docstream's read-only `DemoViewer` renders the demo: from the
239
+ `demoResolver` when it knows `src`, otherwise from the inline files — so a
240
+ pasted docs page looks like the docs page. Inline files run in the Preview
241
+ through `demoRuntime` (e.g. `createAlmostNodeDemoRuntime()` from
242
+ `@brett_lamy/docstream/playground`) with `demoDependencies`; without a runtime
243
+ the viewer shows their code.
244
+
245
+ ```tsx
246
+ import { createGlobDemoResolver } from "@brett_lamy/docstream/demo"
247
+ import { GitbookEditor } from "@brett_lamy/docstream-editor"
248
+
249
+ const demoResolver = createGlobDemoResolver({ /* see the Docstream README */ })
250
+
251
+ <GitbookEditor markdown={markdown} onChange={setMarkdown} demoResolver={demoResolver} />
252
+ ```
253
+
254
+ ## Command boxes
255
+
256
+ `{% command %}npm install @brett_lamy/ui{% endcommand %}` (or the block form, for
257
+ several lines) becomes a `gbCommand` atom node rendered with Docstream's own
258
+ command box: terminal glyph, pnpm / npm / yarn / bun switch (synced page-wide),
259
+ copy button. When editable, the npm command and per-manager overrides
260
+ (`pnpm="…"`, `yarn="…"`, `bun="…"`; the placeholder shows the derived command)
261
+ are edited under the box. `sync` is preserved. Insert one with `/install command`.
262
+
204
263
  ## Slash Menu
205
264
 
206
265
  The editor includes a slash menu extension for inserting supported block structures. Type `/` in an empty paragraph to open block insertion options.
@@ -227,6 +286,7 @@ Exports:
227
286
  - `PMNode`
228
287
  - `EditorAttachment`
229
288
  - `serializeEditorMarkdown`
289
+ - `markdownPasteContent` (the Markdown-paste parser behind `markdownPaste`)
230
290
  - `SourceFileEditor`
231
291
  - `SourceFileEditorProps`
232
292
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brett_lamy/docstream-editor",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "TipTap editor for Docstream GitBook-style markdown documents.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -12,6 +12,7 @@
12
12
  "files": [
13
13
  "src",
14
14
  "!src/**/*.test.ts",
15
+ "!src/**/__fixtures__",
15
16
  "README.md"
16
17
  ],
17
18
  "sideEffects": [
@@ -33,7 +34,7 @@
33
34
  "./styles.css": "./src/styles.css"
34
35
  },
35
36
  "dependencies": {
36
- "@brett_lamy/docstream": "1.0.0",
37
+ "@brett_lamy/docstream": "1.2.1",
37
38
  "gpu-lexer": "0.0.2",
38
39
  "lucide-react": "^1.17.0"
39
40
  },
@@ -11,6 +11,7 @@ import {
11
11
  } from "lucide-react"
12
12
 
13
13
  import { parseMarkdown, type CitationDef } from "@brett_lamy/docstream/gitbook"
14
+ import type { DemoResolver, InlineDemoRuntime } from "@brett_lamy/docstream/demo"
14
15
  import type { SourceFileSnapshot, SourceReferenceClient } from "@brett_lamy/docstream/source"
15
16
  import {
16
17
  attachmentName,
@@ -20,7 +21,7 @@ import {
20
21
  readFileAsDataURL,
21
22
  type EditorAttachment,
22
23
  } from "./attachments"
23
- import { astToTiptap, serializeEditorMarkdown, tiptapToAst, type PMNode } from "./convert"
24
+ import { astToTiptap, markdownPasteContent, serializeEditorMarkdown, tiptapToAst, type PMNode } from "./convert"
24
25
  import { createGitbookExtensions, type ReferenceSources } from "./extensions"
25
26
  import { EditorRuntimeProvider } from "./runtime"
26
27
  import type { SlashItem } from "./slash-menu"
@@ -86,6 +87,25 @@ export interface GitbookEditorProps {
86
87
  onSourceSaved?: (snapshot: SourceFileSnapshot) => void
87
88
  /** Called when reading, writing, or previewing a file-backed source fails. */
88
89
  onSourceError?: (error: Error) => void
90
+ /**
91
+ * Resolves `{% demo src %}` ids. Demo blocks show docstream's (read-only) DemoViewer under
92
+ * their header: from the resolver when it knows `src`, otherwise from the files the block
93
+ * carries inline (a pasted Copy-page export), so the block looks like the docs page.
94
+ */
95
+ demoResolver?: DemoResolver
96
+ /**
97
+ * Runs the Preview of demos that carry their files inline and have no resolver behind them
98
+ * (as `DocRenderOptions.demoRuntime`), e.g. `createAlmostNodeDemoRuntime()` from
99
+ * `@brett_lamy/docstream/playground`. Without one, such demos show their code with a note.
100
+ */
101
+ demoRuntime?: InlineDemoRuntime
102
+ /** Extra npm dependencies inline demos may import (as `DocRenderOptions.demoDependencies`). */
103
+ demoDependencies?: Record<string, string>
104
+ /**
105
+ * Parse pasted plain-text Markdown that contains blocks (a docs page's "Copy page", a
106
+ * `{% … %}` block, a fence, a list …) into editor blocks instead of literal text (default true).
107
+ */
108
+ markdownPaste?: boolean
89
109
  }
90
110
 
91
111
  function ToolbarButton({
@@ -173,6 +193,10 @@ export function GitbookEditor({
173
193
  sourceAutoSave = true,
174
194
  onSourceSaved,
175
195
  onSourceError,
196
+ demoResolver,
197
+ demoRuntime,
198
+ demoDependencies,
199
+ markdownPaste = true,
176
200
  }: GitbookEditorProps) {
177
201
  // Tracks the markdown the editor itself produced, so external updates
178
202
  // (file switches) reset content but our own onChange echoes don't.
@@ -184,8 +208,8 @@ export function GitbookEditor({
184
208
  // Ids of the attachment chips in the document, to report removals.
185
209
  const attachmentIds = useRef<Set<string>>(new Set())
186
210
  // Latest callbacks for handlers the editor captured at creation.
187
- const latest = useRef({ onPaste, imagePaste, onAttachmentAdd, onAttachmentRemove })
188
- latest.current = { onPaste, imagePaste, onAttachmentAdd, onAttachmentRemove }
211
+ const latest = useRef({ onPaste, imagePaste, onAttachmentAdd, onAttachmentRemove, markdownPaste })
212
+ latest.current = { onPaste, imagePaste, onAttachmentAdd, onAttachmentRemove, markdownPaste }
189
213
  const editorRef = useRef<TiptapEditor | null>(null)
190
214
 
191
215
  // Pasted/dropped image files: image blocks ("inline") or attachment chips ("chip").
@@ -232,6 +256,27 @@ export function GitbookEditor({
232
256
  })
233
257
  }
234
258
 
259
+ // Plain-text Markdown (e.g. a docs page's "Copy page") becomes blocks, not literal text.
260
+ // Rich HTML clipboards keep ProseMirror's own handling; code editors (VS Code) also
261
+ // put styled HTML there, so their payload still counts as plain text.
262
+ const pasteMarkdown = (event: ClipboardEvent): boolean => {
263
+ const data = event.clipboardData
264
+ const ed = editorRef.current
265
+ if (!data || !ed) return false
266
+ const types = Array.from(data.types ?? [])
267
+ if (types.includes("text/html") && !types.includes("vscode-editor-data")) return false
268
+ const pasted = markdownPasteContent(data.getData("text/plain"))
269
+ if (!pasted?.content.length) return false
270
+ event.preventDefault()
271
+ if (pasted.citations) {
272
+ const known = new Map((citationsRef.current ?? []).map((c) => [c.id, c]))
273
+ for (const c of pasted.citations) known.set(c.id, c)
274
+ citationsRef.current = [...known.values()]
275
+ }
276
+ ed.chain().focus().insertContent(pasted.content).run()
277
+ return true
278
+ }
279
+
235
280
  const parseAndTrack = (md: string) => {
236
281
  const doc = parseMarkdown(md)
237
282
  citationsRef.current = doc.citations
@@ -254,10 +299,12 @@ export function GitbookEditor({
254
299
  if (latest.current.onPaste?.(event)) return true
255
300
  if (!view.editable || view.state.selection.$from.parent.type.spec.code) return false
256
301
  const files = imageFilesFrom(event.clipboardData, { ignoreWithText: true })
257
- if (!files.length) return false
258
- event.preventDefault()
259
- insertImageFiles(files)
260
- return true
302
+ if (files.length) {
303
+ event.preventDefault()
304
+ insertImageFiles(files)
305
+ return true
306
+ }
307
+ return latest.current.markdownPaste ? pasteMarkdown(event) : false
261
308
  },
262
309
  handleDrop: (view, event, _slice, moved) => {
263
310
  if (moved || !view.editable) return false
@@ -312,9 +359,12 @@ export function GitbookEditor({
312
359
  sourceAutoSave,
313
360
  ...(onSourceSaved ? { onSourceSaved } : {}),
314
361
  ...(onSourceError ? { onSourceError } : {}),
362
+ ...(demoResolver ? { demoResolver } : {}),
363
+ ...(demoRuntime ? { demoRuntime } : {}),
364
+ ...(demoDependencies ? { demoDependencies } : {}),
315
365
  ...(attachments ? { attachments } : {}),
316
366
  ...(onAttachmentOpen ? { onAttachmentOpen } : {}),
317
- }), [attachments, onAttachmentOpen, onSourceError, onSourceSaved, sourceAutoSave, sourceClient, sourcePreview])
367
+ }), [attachments, demoDependencies, demoResolver, demoRuntime, onAttachmentOpen, onSourceError, onSourceSaved, sourceAutoSave, sourceClient, sourcePreview])
318
368
 
319
369
  if (!editor) return null
320
370
 
@@ -1,7 +1,14 @@
1
1
  import {
2
+ parseMarkdown,
2
3
  plainText,
3
4
  serializeMarkdown,
4
5
  type Block,
6
+ type CitationDef,
7
+ type DemoLayout,
8
+ type CommandNode,
9
+ type DemoInlineFile,
10
+ type DemoNode,
11
+ type DemoViewport,
5
12
  type DocumentNode,
6
13
  type Inline,
7
14
  type ListItemNode,
@@ -56,7 +63,8 @@ function inlineToPM(nodes: Inline[]): PMNode[] {
56
63
  if (n.italic) marks.push({ type: "italic" })
57
64
  if (n.strike) marks.push({ type: "strike" })
58
65
  if (n.code) marks.push({ type: "code" })
59
- if (n.link) marks.push({ type: "link", attrs: { href: n.link } })
66
+ // `inner` keeps `**[x](url)**` (link inside the emphasis) apart from `[**x**](url)`.
67
+ if (n.link) marks.push({ type: "link", attrs: { href: n.link, ...(n.linkInner ? { inner: true } : {}) } })
60
68
  return { type: "text", text: n.text, ...(marks.length ? { marks } : {}) }
61
69
  })
62
70
  }
@@ -93,20 +101,38 @@ function blockToPM(b: Block): PMNode {
93
101
  lineNumbers: b.lineNumbers,
94
102
  live: !!b.live,
95
103
  entry: b.entry ?? null,
104
+ collapsedCodeLines: b.collapsedCodeLines ?? null,
105
+ expandedCodeLines: b.expandedCodeLines ?? null,
96
106
  },
97
107
  content: b.code ? [{ type: "text", text: b.code }] : [],
98
108
  }
99
109
  case "hint":
100
110
  return { type: "gbHint", attrs: { style: b.style }, content: blocksPM(b.children) }
101
- case "tabs":
111
+ case "tabs": {
112
+ const attrs = {
113
+ ...(b.sync ? { sync: b.sync } : {}),
114
+ ...(b.title ? { title: b.title } : {}),
115
+ ...(b.level ? { level: b.level } : {}),
116
+ }
102
117
  return {
103
118
  type: "gbTabs",
119
+ ...(Object.keys(attrs).length ? { attrs } : {}),
104
120
  content: b.tabs.map((t) => ({
105
121
  type: "gbTab",
106
122
  attrs: { title: t.title },
107
123
  content: blocksPM(t.children),
108
124
  })),
109
125
  }
126
+ }
127
+ case "command":
128
+ return {
129
+ type: "gbCommand",
130
+ attrs: {
131
+ command: b.command,
132
+ overrides: b.overrides && Object.keys(b.overrides).length ? { ...b.overrides } : null,
133
+ sync: b.sync ?? null,
134
+ },
135
+ }
110
136
  case "expandable":
111
137
  return {
112
138
  type: "gbExpandable",
@@ -132,8 +158,13 @@ function blockToPM(b: Block): PMNode {
132
158
  if (b.controls !== undefined) attrs.controls = b.controls
133
159
  return { type: "gbEmbed", attrs }
134
160
  }
135
- case "content-ref":
136
- return { type: "gbContentRef", attrs: { url: b.url, label: plainText(b.children) } }
161
+ case "content-ref": {
162
+ // GitBook writes the label as a link to the page (`[Card](card.md)`); remember
163
+ // whether this one was, so a plain label stays plain.
164
+ const only = b.children.length === 1 ? b.children[0] : undefined
165
+ const linked = only?.type === "text" && only.link === b.url && !only.bold && !only.italic && !only.strike && !only.code
166
+ return { type: "gbContentRef", attrs: { url: b.url, label: plainText(b.children), linked } }
167
+ }
137
168
  case "source-ref":
138
169
  return {
139
170
  type: "gbSourceRef",
@@ -145,6 +176,21 @@ function blockToPM(b: Block): PMNode {
145
176
  title: b.title ?? "",
146
177
  },
147
178
  }
179
+ case "demo":
180
+ return {
181
+ type: "gbDemo",
182
+ attrs: {
183
+ src: b.src,
184
+ title: b.title ?? "",
185
+ description: b.description ?? "",
186
+ height: b.height ?? "",
187
+ layout: b.layout ?? null,
188
+ variants: b.variants?.length ? b.variants.map((v) => ({ id: v.id, label: v.label })) : null,
189
+ viewport: b.viewport ?? null,
190
+ entry: b.entry ?? null,
191
+ files: b.files?.length ? b.files.map((f) => ({ ...f })) : null,
192
+ },
193
+ }
148
194
  case "columns":
149
195
  return {
150
196
  type: "gbColumns",
@@ -262,8 +308,12 @@ function pmTextToInline(nodes: PMNode[] | undefined): Inline[] {
262
308
  if (mark.type === "italic") inline.italic = true
263
309
  if (mark.type === "strike") inline.strike = true
264
310
  if (mark.type === "code") inline.code = true
265
- if (mark.type === "link") inline.link = String(mark.attrs?.href ?? "")
311
+ if (mark.type === "link") {
312
+ inline.link = String(mark.attrs?.href ?? "")
313
+ if (mark.attrs?.inner) inline.linkInner = true
314
+ }
266
315
  }
316
+ if (inline.linkInner && !(inline.bold || inline.italic || inline.strike)) delete inline.linkInner
267
317
  return inline
268
318
  })
269
319
  }
@@ -286,7 +336,9 @@ function pmToBlock(n: PMNode): Block | null {
286
336
  level: (Number(n.attrs?.level) || 1) as 1 | 2 | 3 | 4 | 5 | 6,
287
337
  children: pmTextToInline(n.content),
288
338
  }
289
- case "codeBlock":
339
+ case "codeBlock": {
340
+ const collapsed = Number(n.attrs?.collapsedCodeLines) || 0
341
+ const expanded = Number(n.attrs?.expandedCodeLines) || 0
290
342
  return {
291
343
  type: "code",
292
344
  language: (n.attrs?.language as string) || null,
@@ -294,15 +346,19 @@ function pmToBlock(n: PMNode): Block | null {
294
346
  lineNumbers: !!n.attrs?.lineNumbers,
295
347
  live: !!n.attrs?.live,
296
348
  entry: (n.attrs?.entry as string) || null,
349
+ ...(collapsed ? { collapsedCodeLines: collapsed } : {}),
350
+ ...(expanded ? { expandedCodeLines: expanded } : {}),
297
351
  code: n.content?.map((c) => c.text ?? "").join("") ?? "",
298
352
  }
353
+ }
299
354
  case "gbHint":
300
355
  return {
301
356
  type: "hint",
302
357
  style: (n.attrs?.style as never) ?? "info",
303
358
  children: pmToBlocks(n.content),
304
359
  }
305
- case "gbTabs":
360
+ case "gbTabs": {
361
+ const level = Number(n.attrs?.level)
306
362
  return {
307
363
  type: "tabs",
308
364
  tabs: (n.content ?? []).map((t) => ({
@@ -310,7 +366,13 @@ function pmToBlock(n: PMNode): Block | null {
310
366
  title: String(t.attrs?.title ?? "Tab"),
311
367
  children: pmToBlocks(t.content),
312
368
  })),
369
+ ...(n.attrs?.sync ? { sync: String(n.attrs.sync) } : {}),
370
+ ...(n.attrs?.title ? { title: String(n.attrs.title) } : {}),
371
+ ...(level === 2 || level === 3 || level === 4 ? { level } : {}),
313
372
  }
373
+ }
374
+ case "gbCommand":
375
+ return pmToCommand(n.attrs ?? {})
314
376
  case "gbExpandable":
315
377
  return {
316
378
  type: "expandable",
@@ -339,12 +401,16 @@ function pmToBlock(n: PMNode): Block | null {
339
401
  if (typeof n.attrs?.controls === "boolean") embed.controls = n.attrs.controls
340
402
  return embed
341
403
  }
342
- case "gbContentRef":
404
+ case "gbContentRef": {
405
+ const url = String(n.attrs?.url ?? "")
406
+ const label = String(n.attrs?.label ?? "")
407
+ const linked = n.attrs?.linked !== false && !!url && !!label
343
408
  return {
344
409
  type: "content-ref",
345
- url: String(n.attrs?.url ?? ""),
346
- children: [{ type: "text", text: String(n.attrs?.label ?? "") }],
410
+ url,
411
+ children: label ? [{ type: "text", text: label, ...(linked ? { link: url } : {}) }] : [],
347
412
  }
413
+ }
348
414
  case "gbSourceRef":
349
415
  return {
350
416
  type: "source-ref",
@@ -354,6 +420,8 @@ function pmToBlock(n: PMNode): Block | null {
354
420
  kind: n.attrs?.kind === "story" ? "story" : "component",
355
421
  ...(n.attrs?.title ? { title: String(n.attrs.title) } : {}),
356
422
  }
423
+ case "gbDemo":
424
+ return pmToDemo(n.attrs ?? {})
357
425
  case "gbColumns":
358
426
  return {
359
427
  type: "columns",
@@ -423,6 +491,60 @@ function pmToBlock(n: PMNode): Block | null {
423
491
  }
424
492
  }
425
493
 
494
+ const DEMO_LAYOUTS: readonly string[] = ["auto", "single", "multi"] satisfies DemoLayout[]
495
+ const DEMO_VIEWPORTS: readonly string[] = ["desktop", "tablet", "phone"] satisfies DemoViewport[]
496
+
497
+ /** `gbDemo` attrs → `DemoNode`; also used by the node view to build the preview. */
498
+ export function pmToDemo(a: Record<string, unknown>): DemoNode {
499
+ const variants = Array.isArray(a.variants)
500
+ ? (a.variants as Array<{ id?: unknown; label?: unknown }>)
501
+ .filter((v) => v && v.id)
502
+ .map((v) => ({ id: String(v.id), label: String(v.label || v.id) }))
503
+ : []
504
+ const layout = String(a.layout ?? "")
505
+ const files = pmDemoFiles(a.files)
506
+ const viewport = String(a.viewport ?? "")
507
+ return {
508
+ type: "demo",
509
+ src: String(a.src ?? ""),
510
+ ...(a.title ? { title: String(a.title) } : {}),
511
+ ...(a.description ? { description: String(a.description) } : {}),
512
+ ...(a.height ? { height: String(a.height) } : {}),
513
+ ...(DEMO_LAYOUTS.includes(layout) ? { layout: layout as DemoLayout } : {}),
514
+ ...(variants.length ? { variants } : {}),
515
+ ...(DEMO_VIEWPORTS.includes(viewport) ? { viewport: viewport as DemoViewport } : {}),
516
+ ...(a.entry ? { entry: String(a.entry) } : {}),
517
+ ...(files.length ? { files } : {}),
518
+ }
519
+ }
520
+
521
+ /** `gbDemo`'s `files` attr → inline demo files (malformed entries dropped). */
522
+ export function pmDemoFiles(value: unknown): DemoInlineFile[] {
523
+ if (!Array.isArray(value)) return []
524
+ return (value as Array<Record<string, unknown> | null>)
525
+ .filter((f): f is Record<string, unknown> => !!f && typeof f === "object")
526
+ .map((f) => ({
527
+ path: String(f.path ?? ""),
528
+ content: String(f.content ?? ""),
529
+ ...(f.language ? { language: String(f.language) } : {}),
530
+ }))
531
+ }
532
+
533
+ const COMMAND_OVERRIDES = ["pnpm", "yarn", "bun"] as const
534
+
535
+ /** `gbCommand` attrs → `CommandNode`; also used by the node view to render the box. */
536
+ export function pmToCommand(a: Record<string, unknown>): CommandNode {
537
+ const raw = (a.overrides && typeof a.overrides === "object" ? a.overrides : {}) as Record<string, unknown>
538
+ const overrides: NonNullable<CommandNode["overrides"]> = {}
539
+ for (const pm of COMMAND_OVERRIDES) if (raw[pm]) overrides[pm] = String(raw[pm])
540
+ return {
541
+ type: "command",
542
+ command: String(a.command ?? ""),
543
+ ...(Object.keys(overrides).length ? { overrides } : {}),
544
+ ...(a.sync ? { sync: String(a.sync) } : {}),
545
+ }
546
+ }
547
+
426
548
  function pmToBlocks(nodes: PMNode[] | undefined): Block[] {
427
549
  return (nodes ?? []).map(pmToBlock).filter((b): b is Block => b !== null)
428
550
  }
@@ -446,3 +568,17 @@ export function serializeEditorMarkdown(doc: DocumentNode): string {
446
568
  alt.includes("]") ? img : `![${alt}](${src})`
447
569
  )
448
570
  }
571
+
572
+ /**
573
+ * Plain-text Markdown pasted into the editor (a docs page's "Copy page", a `{% … %}`
574
+ * block, a fence, a list, a heading…) as editor blocks, or null when the text is just
575
+ * prose — which the editor then pastes as text, as usual.
576
+ */
577
+ export function markdownPasteContent(text: string): { content: PMNode[]; citations?: CitationDef[] } | null {
578
+ if (!text.trim()) return null
579
+ const doc = parseMarkdown(text)
580
+ const blocks = doc.children.some((b) => b.type !== "paragraph") || /(^|\n)\s*\{%\s*[a-z-]+/.test(text)
581
+ if (!blocks && !doc.citations?.length) return null
582
+ const content = astToTiptap(doc).content ?? []
583
+ return { content, ...(doc.citations?.length ? { citations: doc.citations } : {}) }
584
+ }
@@ -2,7 +2,7 @@ import StarterKit from "@tiptap/starter-kit"
2
2
  import Placeholder from "@tiptap/extension-placeholder"
3
3
  import { TaskItem, TaskList } from "@tiptap/extension-list"
4
4
  import { Table, TableCell, TableHeader, TableRow } from "@tiptap/extension-table"
5
- import { getSchema, type AnyExtension } from "@tiptap/core"
5
+ import { Extension, getSchema, type AnyExtension } from "@tiptap/core"
6
6
  import type { Schema } from "@tiptap/pm/model"
7
7
  import { GbCodeBlock, gitbookNodes } from "./nodes"
8
8
  import { SlashMenu, createSlashMenu, type SlashItem } from "./slash-menu"
@@ -15,6 +15,29 @@ export const GbTable = Table.extend({
15
15
  },
16
16
  })
17
17
 
18
+ /**
19
+ * `inner` on the link mark: the link sits inside its emphasis (`**[x](url)**`)
20
+ * rather than around it (`[**x**](url)`). Kept so pasted Markdown round-trips
21
+ * byte-for-byte; newly created links default to the outer form.
22
+ */
23
+ export const GbLinkInner = Extension.create({
24
+ name: "gbLinkInner",
25
+ addGlobalAttributes() {
26
+ return [
27
+ {
28
+ types: ["link"],
29
+ attributes: {
30
+ inner: {
31
+ default: null,
32
+ parseHTML: (element) => (element.hasAttribute("data-link-inner") ? true : null),
33
+ renderHTML: (attributes) => (attributes.inner ? { "data-link-inner": "" } : {}),
34
+ },
35
+ },
36
+ },
37
+ ]
38
+ },
39
+ })
40
+
18
41
  export interface ReferenceSources {
19
42
  /** "@" — people, agents, … */
20
43
  mentions?: ReferenceSource
@@ -71,6 +94,7 @@ export function createGitbookExtensions(options: GitbookExtensionOptions = {}):
71
94
  TableHeader,
72
95
  TableCell,
73
96
  Placeholder.configure({ placeholder }),
97
+ GbLinkInner,
74
98
  ...gitbookNodes,
75
99
  ]
76
100
 
@@ -10,11 +10,13 @@ import {
10
10
  } from "@tiptap/react"
11
11
  import {
12
12
  AlertTriangle,
13
+ AppWindow,
13
14
  AtSign,
14
15
  CheckCircle2,
15
16
  ChevronDown,
16
17
  FolderGit2,
17
18
  Hash,
19
+ Heading,
18
20
  Info,
19
21
  Link2,
20
22
  FileCode2,
@@ -26,10 +28,12 @@ import {
26
28
  import { DocsRenderer } from "@brett_lamy/docstream"
27
29
  import { resolveAsset } from "@brett_lamy/docstream/assets"
28
30
  import { OpenApiOperation } from "@brett_lamy/docstream/openapi"
29
- import type { DocumentNode, HintStyle, SourceRefNode } from "@brett_lamy/docstream/gitbook"
31
+ import { packageManagerCommands, type DemoInlineFile, type DocumentNode, type HintStyle, type SourceRefNode } from "@brett_lamy/docstream/gitbook"
30
32
  import { ReactCodePreview } from "@brett_lamy/docstream/playground"
31
33
  import { ReplayPreview, isReplayQaUrl } from "@brett_lamy/docstream/replay"
32
34
  import { VideoEmbed } from "@brett_lamy/docstream/video"
35
+ import { DemoViewer } from "@brett_lamy/docstream/demo"
36
+ import { pmToCommand, pmToDemo } from "./convert"
33
37
  import { SourceFileEditor } from "./SourceFileEditor"
34
38
  import { useEditorRuntime } from "./runtime"
35
39
  import { GbAttachment } from "./attachments"
@@ -132,9 +136,14 @@ function TabsView({ node, editor, getPos, updateAttributes }: NodeViewProps) {
132
136
  updateAttributes({ active: Math.max(0, active - (index <= active ? 1 : 0)) })
133
137
  }
134
138
 
135
- return (
136
- <NodeViewWrapper className="gb-tabs">
137
- <div className="gb-tabs-header" contentEditable={false}>
139
+ const editable = editor.isEditable
140
+ const title = typeof node.attrs.title === "string" ? node.attrs.title : ""
141
+ const level = node.attrs.level === 3 || node.attrs.level === 4 ? node.attrs.level : 2
142
+ // `{% tabs title="…" %}`: the set renders as a section — heading, then the switch on its row.
143
+ const section = !!title || node.attrs.titled === true
144
+
145
+ const tabStrip = (
146
+ <>
138
147
  {titles.map((t, idx) => (
139
148
  <div
140
149
  key={idx}
@@ -158,11 +167,67 @@ function TabsView({ node, editor, getPos, updateAttributes }: NodeViewProps) {
158
167
  )}
159
168
  </div>
160
169
  ))}
161
- {editor.isEditable && (
170
+ {editable && (
162
171
  <button className="gb-icon-btn" onClick={addTab} title="Add tab">
163
172
  <Plus className="size-3.5" />
164
173
  </button>
165
174
  )}
175
+ {editable && !section && (
176
+ <button
177
+ className="gb-icon-btn gb-tabs-add-title"
178
+ onClick={() => updateAttributes({ titled: true })}
179
+ title="Add a section title (renders the tabs as a titled section)"
180
+ >
181
+ <Heading className="size-3.5" />
182
+ </button>
183
+ )}
184
+ </>
185
+ )
186
+
187
+ if (section) {
188
+ const HeadingTag = `h${level}` as "h2" | "h3" | "h4"
189
+ return (
190
+ <NodeViewWrapper className="gb-tabs gb-tabs-section" data-level={level}>
191
+ <div className="gb-tabs-section-head" contentEditable={false}>
192
+ <HeadingTag className="gb-tabs-section-title">
193
+ {editable ? (
194
+ <input
195
+ className="gb-inline-input gb-tabs-section-input"
196
+ value={title}
197
+ placeholder="Section title…"
198
+ autoFocus={!title}
199
+ onChange={(e) => updateAttributes({ title: e.target.value || null })}
200
+ onBlur={(e) => {
201
+ if (!e.target.value) updateAttributes({ title: null, titled: false })
202
+ }}
203
+ />
204
+ ) : (
205
+ title
206
+ )}
207
+ </HeadingTag>
208
+ {editable && (
209
+ <select
210
+ className="gb-tabs-level"
211
+ value={level}
212
+ title="Heading level"
213
+ onChange={(e) => updateAttributes({ level: Number(e.target.value) === 2 ? null : Number(e.target.value) })}
214
+ >
215
+ <option value={2}>H2</option>
216
+ <option value={3}>H3</option>
217
+ <option value={4}>H4</option>
218
+ </select>
219
+ )}
220
+ <div className="gb-tabs-pills">{tabStrip}</div>
221
+ </div>
222
+ <NodeViewContent className="gb-tabs-content" data-active={active} />
223
+ </NodeViewWrapper>
224
+ )
225
+ }
226
+
227
+ return (
228
+ <NodeViewWrapper className="gb-tabs">
229
+ <div className="gb-tabs-header" contentEditable={false}>
230
+ {tabStrip}
166
231
  </div>
167
232
  <NodeViewContent className="gb-tabs-content" data-active={active} />
168
233
  </NodeViewWrapper>
@@ -176,7 +241,28 @@ export const GbTabs = Node.create({
176
241
  defining: true,
177
242
  isolating: true,
178
243
  addAttributes() {
179
- return { active: { default: 0, rendered: false } }
244
+ return {
245
+ active: { default: 0, rendered: false },
246
+ // `{% tabs sync="key" %}` — tab sets sharing a key follow the reader's last choice.
247
+ sync: {
248
+ default: null,
249
+ parseHTML: (element) => element.getAttribute("data-sync") || null,
250
+ renderHTML: (attributes) => (attributes.sync ? { "data-sync": attributes.sync } : {}),
251
+ },
252
+ // `{% tabs title="Installation" level="3" %}` — a titled set renders as a section heading.
253
+ title: {
254
+ default: null,
255
+ parseHTML: (element) => element.getAttribute("data-title") || null,
256
+ renderHTML: (attributes) => (attributes.title ? { "data-title": attributes.title } : {}),
257
+ },
258
+ level: {
259
+ default: null,
260
+ parseHTML: (element) => Number(element.getAttribute("data-level")) || null,
261
+ renderHTML: (attributes) => (attributes.level ? { "data-level": attributes.level } : {}),
262
+ },
263
+ // UI only: a title field is open but still empty.
264
+ titled: { default: false, rendered: false },
265
+ }
180
266
  },
181
267
  parseHTML() {
182
268
  return [{ tag: "div[data-gb-tabs]" }]
@@ -432,7 +518,8 @@ export const GbContentRef = Node.create({
432
518
  group: "block",
433
519
  atom: true,
434
520
  addAttributes() {
435
- return { url: { default: "" }, label: { default: "" } }
521
+ // `linked`: the label is written as a link to the page (`[Card](card.md)`, GitBook's form).
522
+ return { url: { default: "" }, label: { default: "" }, linked: { default: true, rendered: false } }
436
523
  },
437
524
  parseHTML() {
438
525
  return [{ tag: "div[data-gb-content-ref]" }]
@@ -515,6 +602,308 @@ export const GbSourceRef = Node.create({
515
602
  },
516
603
  })
517
604
 
605
+ // ---------- Demo ----------
606
+
607
+ /** Inline demo files editor: a tab per file (path editable), a code area, add / remove. */
608
+ function DemoFilesEditor({ files, entry, onChange }: {
609
+ files: DemoInlineFile[]
610
+ entry: string
611
+ onChange: (files: DemoInlineFile[], entry?: string | null) => void
612
+ }) {
613
+ const [selected, setSelected] = useState(0)
614
+ const index = Math.min(selected, Math.max(0, files.length - 1))
615
+ const file = files[index]
616
+ const update = (patch: Partial<DemoInlineFile>) => {
617
+ const next = files.map((f, i) => {
618
+ if (i !== index) return f
619
+ const merged = { ...f, ...patch }
620
+ if (!merged.language) delete merged.language
621
+ return merged
622
+ })
623
+ // Renaming the entry file keeps it the entry.
624
+ const renamed = patch.path !== undefined && file && entry === file.path ? patch.path : undefined
625
+ onChange(next, renamed)
626
+ }
627
+ const add = () => {
628
+ const taken = new Set(files.map((f) => f.path))
629
+ let n = files.length + 1
630
+ let path = files.length ? `file${n}.tsx` : "index.tsx"
631
+ while (taken.has(path)) path = `file${++n}.tsx`
632
+ onChange([...files, { path, content: "", language: "tsx" }])
633
+ setSelected(files.length)
634
+ }
635
+ const remove = (i: number) => {
636
+ const removed = files[i]
637
+ onChange(files.filter((_, k) => k !== i), removed && entry === removed.path ? null : undefined)
638
+ setSelected(Math.max(0, i - 1))
639
+ }
640
+ return (
641
+ <div className="gb-demo-files">
642
+ <div className="gb-demo-files-tabs" role="tablist" aria-label="Demo files">
643
+ {files.map((f, i) => (
644
+ <div key={i} className={i === index ? "gb-demo-file-tab gb-demo-file-tab-active" : "gb-demo-file-tab"} onClick={() => setSelected(i)}>
645
+ {i === index ? (
646
+ <input
647
+ className="gb-demo-file-path"
648
+ value={f.path}
649
+ size={Math.max(f.path.length, 6)}
650
+ placeholder="index.tsx"
651
+ aria-label="File path"
652
+ onChange={(e) => update({ path: e.target.value })}
653
+ />
654
+ ) : (
655
+ <span>{f.path || "untitled"}</span>
656
+ )}
657
+ {f.path && entry === f.path ? <span className="gb-demo-file-entry">entry</span> : null}
658
+ <button type="button" className="gb-icon-btn" title={`Remove ${f.path || "file"}`} onClick={(e) => { e.stopPropagation(); remove(i) }}>
659
+ <X className="size-3" />
660
+ </button>
661
+ </div>
662
+ ))}
663
+ <button type="button" className="gb-icon-btn" title="Add file" onClick={add}>
664
+ <Plus className="size-3.5" />
665
+ </button>
666
+ </div>
667
+ {file ? (
668
+ <div className="gb-demo-file-body">
669
+ <div className="gb-demo-file-meta">
670
+ <input
671
+ className="gb-inline-input gb-demo-file-lang"
672
+ value={file.language ?? ""}
673
+ placeholder="lang"
674
+ aria-label="Language"
675
+ onChange={(e) => update({ language: e.target.value })}
676
+ />
677
+ <label className="gb-demo-file-entry-toggle">
678
+ <input
679
+ type="radio"
680
+ checked={entry ? entry === file.path : index === 0}
681
+ onChange={() => onChange(files, index === 0 ? null : file.path)}
682
+ />
683
+ Entry
684
+ </label>
685
+ </div>
686
+ <textarea
687
+ className="gb-demo-file-code"
688
+ value={file.content}
689
+ spellCheck={false}
690
+ rows={Math.min(24, Math.max(4, file.content.split("\n").length + 1))}
691
+ aria-label={`Edit ${file.path}`}
692
+ onChange={(e) => update({ content: e.target.value })}
693
+ onKeyDown={(e) => {
694
+ if (e.key !== "Tab" || e.shiftKey) return
695
+ e.preventDefault()
696
+ const el = e.currentTarget
697
+ const { selectionStart: a, selectionEnd: b } = el
698
+ update({ content: `${el.value.slice(0, a)} ${el.value.slice(b)}` })
699
+ requestAnimationFrame(() => el.setSelectionRange(a + 2, a + 2))
700
+ }}
701
+ />
702
+ </div>
703
+ ) : (
704
+ <div className="gb-demo-files-empty">No inline files — the demo resolves from its <code>src</code>.</div>
705
+ )}
706
+ </div>
707
+ )
708
+ }
709
+
710
+ function DemoView({ node, updateAttributes, editor }: NodeViewProps) {
711
+ const editable = editor.isEditable
712
+ const { demoResolver, demoRuntime, demoDependencies } = useEditorRuntime()
713
+ const demo = useMemo(() => pmToDemo(node.attrs), [node.attrs])
714
+ const [filesOpen, setFilesOpen] = useState(false)
715
+ // Typing into `src` or a file would otherwise re-resolve / re-run every keystroke.
716
+ const src = useDebouncedValue(demo.src)
717
+ const files = useDebouncedValue(demo.files)
718
+ const entry = useDebouncedValue(demo.entry)
719
+ const hasFiles = !!demo.files?.length
720
+ // The resolver is the authority when it knows `src`; inline files stand in otherwise
721
+ // (DemoViewer falls back to them), so a pasted docs page looks like the page.
722
+ const preview = (!!demoResolver && !!src) || !!files?.length
723
+ const setFiles = (next: DemoInlineFile[], nextEntry?: string | null) => {
724
+ updateAttributes({
725
+ files: next.length ? next : null,
726
+ ...(nextEntry !== undefined ? { entry: nextEntry || null } : {}),
727
+ })
728
+ }
729
+ return (
730
+ <NodeViewWrapper className={preview || (editable && filesOpen) ? "gb-demo-block gb-demo-block-preview" : "gb-demo-block"} contentEditable={false}>
731
+ <div className="gb-demo">
732
+ <AppWindow className="size-4 shrink-0" />
733
+ {editable ? (
734
+ <>
735
+ <input className="gb-inline-input gb-demo-src" value={node.attrs.src} placeholder="page/example" onChange={(event) => updateAttributes({ src: event.target.value })} />
736
+ <input className="gb-inline-input gb-demo-title" value={node.attrs.title} placeholder="Title" onChange={(event) => updateAttributes({ title: event.target.value })} />
737
+ <button
738
+ type="button"
739
+ className={filesOpen ? "gb-demo-files-toggle gb-demo-files-toggle-active" : "gb-demo-files-toggle"}
740
+ title="Edit the files carried inline by this demo"
741
+ aria-expanded={filesOpen}
742
+ onClick={() => setFilesOpen((open) => !open)}
743
+ >
744
+ <FileCode2 className="size-3.5" />
745
+ {hasFiles ? `${demo.files!.length} file${demo.files!.length === 1 ? "" : "s"}` : "Files"}
746
+ </button>
747
+ </>
748
+ ) : (
749
+ <>
750
+ <code className="gb-demo-src">{demo.src || "demo"}</code>
751
+ {demo.title ? <span className="gb-demo-title">{demo.title}</span> : null}
752
+ </>
753
+ )}
754
+ </div>
755
+ {editable && filesOpen ? (
756
+ <DemoFilesEditor files={demo.files ?? []} entry={demo.entry ?? ""} onChange={setFiles} />
757
+ ) : null}
758
+ {preview ? (
759
+ <div className="gb-demo-preview">
760
+ <DemoViewer
761
+ key={src}
762
+ {...(demoResolver ? { resolver: demoResolver } : {})}
763
+ src={src}
764
+ title={demo.title}
765
+ description={demo.description}
766
+ height={demo.height}
767
+ layout={demo.layout}
768
+ variants={demo.variants}
769
+ viewport={demo.viewport}
770
+ files={files}
771
+ entry={entry}
772
+ {...(demoRuntime ? { runtime: demoRuntime } : {})}
773
+ {...(demoDependencies ? { dependencies: demoDependencies } : {})}
774
+ />
775
+ </div>
776
+ ) : null}
777
+ </NodeViewWrapper>
778
+ )
779
+ }
780
+
781
+ /**
782
+ * `{% demo %}` — every tag attribute is kept on the node, and the block form's inline
783
+ * files (`files`, in document order) with it, so it round-trips losslessly.
784
+ */
785
+ export const GbDemo = Node.create({
786
+ name: "gbDemo",
787
+ group: "block",
788
+ atom: true,
789
+ addAttributes() {
790
+ return {
791
+ src: { default: "" },
792
+ title: { default: "" },
793
+ description: { default: "" },
794
+ height: { default: "" },
795
+ layout: { default: null },
796
+ variants: { default: null, rendered: false },
797
+ viewport: { default: null },
798
+ entry: { default: null },
799
+ // `{ path, content, language? }[]` — the files between `{% demo %}` and `{% enddemo %}`.
800
+ files: {
801
+ default: null,
802
+ rendered: false,
803
+ parseHTML: (element) => {
804
+ const raw = element.getAttribute("data-files")
805
+ if (!raw) return null
806
+ try {
807
+ return JSON.parse(raw)
808
+ } catch {
809
+ return null
810
+ }
811
+ },
812
+ renderHTML: (attributes) => (attributes.files ? { "data-files": JSON.stringify(attributes.files) } : {}),
813
+ },
814
+ }
815
+ },
816
+ parseHTML() {
817
+ return [{ tag: "div[data-gb-demo]" }]
818
+ },
819
+ renderHTML({ HTMLAttributes, node }) {
820
+ return ["div", mergeAttributes(HTMLAttributes, { "data-gb-demo": node.attrs.src })]
821
+ },
822
+ addNodeView() {
823
+ return ReactNodeViewRenderer(DemoView)
824
+ },
825
+ })
826
+
827
+ // ---------- Command (package-manager install box) ----------
828
+
829
+ const COMMAND_MANAGERS = ["pnpm", "yarn", "bun"] as const
830
+
831
+ function CommandView({ node, updateAttributes, editor }: NodeViewProps) {
832
+ const editable = editor.isEditable
833
+ const command = useMemo(() => pmToCommand(node.attrs), [node.attrs])
834
+ const derived = useMemo(() => packageManagerCommands(command.command), [command.command])
835
+ // docstream's own command box, so the editor shows exactly what readers get.
836
+ const doc = useMemo<DocumentNode>(() => ({ type: "doc", children: [command] }), [command])
837
+ const setOverride = (pm: (typeof COMMAND_MANAGERS)[number], value: string) => {
838
+ const next = { ...(command.overrides ?? {}) }
839
+ if (value) next[pm] = value
840
+ else delete next[pm]
841
+ updateAttributes({ overrides: Object.keys(next).length ? next : null })
842
+ }
843
+ return (
844
+ <NodeViewWrapper className="gb-command" contentEditable={false}>
845
+ {command.command ? (
846
+ <div className="gb-command-preview">
847
+ <DocsRenderer doc={doc} />
848
+ </div>
849
+ ) : null}
850
+ {editable ? (
851
+ <div className="gb-command-fields">
852
+ <label className="gb-command-field gb-command-field-npm">
853
+ <span>npm</span>
854
+ <textarea
855
+ className="gb-command-input"
856
+ value={command.command}
857
+ placeholder="npm install <package>"
858
+ spellCheck={false}
859
+ rows={Math.max(1, command.command.split("\n").length)}
860
+ onChange={(e) => updateAttributes({ command: e.target.value })}
861
+ />
862
+ </label>
863
+ {COMMAND_MANAGERS.map((pm) => (
864
+ <label key={pm} className="gb-command-field">
865
+ <span>{pm}</span>
866
+ <input
867
+ className="gb-inline-input gb-command-override"
868
+ value={command.overrides?.[pm] ?? ""}
869
+ placeholder={derived[pm] || `${pm} (derived)`}
870
+ spellCheck={false}
871
+ title={`Override the derived ${pm} command (leave empty to derive it)`}
872
+ onChange={(e) => setOverride(pm, e.target.value)}
873
+ />
874
+ </label>
875
+ ))}
876
+ </div>
877
+ ) : null}
878
+ </NodeViewWrapper>
879
+ )
880
+ }
881
+
882
+ /** `{% command %}npm install x{% endcommand %}` — one npm command shown for the reader's package manager. */
883
+ export const GbCommand = Node.create({
884
+ name: "gbCommand",
885
+ group: "block",
886
+ atom: true,
887
+ addAttributes() {
888
+ return {
889
+ command: { default: "" },
890
+ // `{ pnpm?, yarn?, bun? }` — verbatim commands replacing the derived ones.
891
+ overrides: { default: null, rendered: false },
892
+ // Sync key; null means docstream's default ("pm").
893
+ sync: { default: null },
894
+ }
895
+ },
896
+ parseHTML() {
897
+ return [{ tag: "div[data-gb-command]" }]
898
+ },
899
+ renderHTML({ HTMLAttributes, node }) {
900
+ return ["div", mergeAttributes(HTMLAttributes, { "data-gb-command": "" }), node.attrs.command]
901
+ },
902
+ addNodeView() {
903
+ return ReactNodeViewRenderer(CommandView)
904
+ },
905
+ })
906
+
518
907
  // ---------- Columns ----------
519
908
 
520
909
  export const GbColumns = Node.create({
@@ -728,6 +1117,8 @@ export const GbCodeBlock = CodeBlock.extend({
728
1117
  lineNumbers: { default: false },
729
1118
  live: { default: false },
730
1119
  entry: { default: null },
1120
+ collapsedCodeLines: { default: null, rendered: false },
1121
+ expandedCodeLines: { default: null, rendered: false },
731
1122
  }
732
1123
  },
733
1124
  addNodeView() {
@@ -975,6 +1366,8 @@ export const gitbookNodes = [
975
1366
  GbEmbed,
976
1367
  GbContentRef,
977
1368
  GbSourceRef,
1369
+ GbDemo,
1370
+ GbCommand,
978
1371
  GbColumns,
979
1372
  GbColumn,
980
1373
  GbFigure,
@@ -1,5 +1,6 @@
1
1
  import { createContext, useContext, type ReactNode } from "react"
2
2
 
3
+ import type { DemoResolver, InlineDemoRuntime } from "@brett_lamy/docstream/demo"
3
4
  import type { SourceFileSnapshot, SourceReferenceClient } from "@brett_lamy/docstream/source"
4
5
  import type { EditorAttachment } from "./attachments"
5
6
 
@@ -9,6 +10,9 @@ export interface EditorRuntimeOptions {
9
10
  sourceAutoSave?: boolean | number
10
11
  onSourceSaved?: (snapshot: SourceFileSnapshot) => void
11
12
  onSourceError?: (error: Error) => void
13
+ demoResolver?: DemoResolver
14
+ demoRuntime?: InlineDemoRuntime
15
+ demoDependencies?: Record<string, string>
12
16
  attachments?: EditorAttachment[]
13
17
  onAttachmentOpen?: (attachment: EditorAttachment) => void
14
18
  }
@@ -1,6 +1,7 @@
1
1
  import { Extension, type Editor, type Range } from "@tiptap/core"
2
2
  import Suggestion from "@tiptap/suggestion"
3
3
  import {
4
+ AppWindow,
4
5
  Columns2,
5
6
  FileCode2,
6
7
  Heading1,
@@ -22,6 +23,7 @@ import {
22
23
  SquareChevronDown,
23
24
  Superscript,
24
25
  Table as TableIcon,
26
+ Terminal,
25
27
  Webhook,
26
28
  Workflow,
27
29
  type LucideIcon,
@@ -71,6 +73,12 @@ export const SLASH_ITEMS: SlashItem[] = [
71
73
  ],
72
74
  }),
73
75
  },
76
+ {
77
+ title: "Install command",
78
+ keywords: "command npm pnpm yarn bun npx package manager install terminal shell",
79
+ icon: Terminal,
80
+ run: insertBlock({ type: "gbCommand", attrs: { command: "", overrides: null, sync: null } }),
81
+ },
74
82
  {
75
83
  title: "Expandable",
76
84
  keywords: "details accordion collapse",
@@ -101,6 +109,12 @@ export const SLASH_ITEMS: SlashItem[] = [
101
109
  icon: Link2,
102
110
  run: insertBlock({ type: "gbContentRef", attrs: { url: "", label: "Page link" } }),
103
111
  },
112
+ {
113
+ title: "Demo",
114
+ keywords: "example live preview component playground",
115
+ icon: AppWindow,
116
+ run: insertBlock({ type: "gbDemo", attrs: { src: "", title: "" } }),
117
+ },
104
118
  {
105
119
  title: "Columns",
106
120
  keywords: "two column layout",
package/src/index.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  export { GitbookEditor } from "./editor/Editor"
2
2
  export type { GitbookEditorProps, EditorAttachment } from "./editor/Editor"
3
- export { astToTiptap, tiptapToAst, serializeEditorMarkdown } from "./editor/convert"
3
+ export { astToTiptap, tiptapToAst, serializeEditorMarkdown, markdownPasteContent } from "./editor/convert"
4
4
  export type { PMNode } from "./editor/convert"
5
5
 
6
6
  // Building blocks for composing a custom editor (e.g. with Yjs collaboration
7
7
  // or a bespoke slash menu) instead of the batteries-included GitbookEditor.
8
- export { createGitbookExtensions, getGitbookSchema, GbTable } from "./editor/extensions"
8
+ export { createGitbookExtensions, getGitbookSchema, GbTable, GbLinkInner } from "./editor/extensions"
9
9
  export type { GitbookExtensionOptions, ReferenceSources } from "./editor/extensions"
10
- export { gitbookNodes, GbCodeBlock, GbReference } from "./editor/nodes"
10
+ export { gitbookNodes, GbCodeBlock, GbCommand, GbDemo, GbReference } from "./editor/nodes"
11
11
  export { GbAttachment, formatBytes } from "./editor/attachments"
12
12
  export { SlashMenu, createSlashMenu, SLASH_ITEMS } from "./editor/slash-menu"
13
13
  export type { SlashItem } from "./editor/slash-menu"
package/src/styles.css CHANGED
@@ -88,6 +88,126 @@
88
88
  width: 8rem;
89
89
  }
90
90
 
91
+ .gb-demo {
92
+ display: flex;
93
+ align-items: center;
94
+ gap: 0.5rem;
95
+ border: 1px solid var(--gb-border);
96
+ border-radius: var(--gb-radius);
97
+ padding: 0.65rem 0.8rem;
98
+ background: var(--gb-muted);
99
+ }
100
+
101
+ .gb-demo-block {
102
+ margin: 0.85em 0;
103
+ }
104
+
105
+ .gb-demo-block-preview > .gb-demo {
106
+ border-radius: var(--gb-radius) var(--gb-radius) 0 0;
107
+ }
108
+
109
+ .gb-demo-src {
110
+ flex: 1;
111
+ }
112
+
113
+ .gb-demo-title {
114
+ width: 12rem;
115
+ }
116
+
117
+ .gb-demo-preview {
118
+ border: 1px solid var(--gb-border);
119
+ border-top: 0;
120
+ border-radius: 0 0 var(--gb-radius) var(--gb-radius);
121
+ overflow: hidden;
122
+ }
123
+
124
+ .gb-demo-preview > * {
125
+ margin: 0;
126
+ }
127
+
128
+ .gb-demo-files-toggle {
129
+ display: inline-flex;
130
+ align-items: center;
131
+ gap: 4px;
132
+ padding: 2px 8px;
133
+ border: 1px solid var(--gb-border);
134
+ border-radius: 999px;
135
+ background: transparent;
136
+ color: var(--gb-muted-foreground);
137
+ font-size: 12px;
138
+ cursor: pointer;
139
+ white-space: nowrap;
140
+ }
141
+ .gb-demo-files-toggle-active { background: var(--gb-bg); color: var(--gb-text); }
142
+
143
+ /* Inline demo files: a tab per file and a code area. */
144
+ .gb-demo-files {
145
+ border: 1px solid var(--gb-border);
146
+ border-top: 0;
147
+ background: var(--gb-panel);
148
+ }
149
+ .gb-demo-block:not(.gb-demo-block-preview) .gb-demo-files,
150
+ .gb-demo-files:last-child {
151
+ border-radius: 0 0 var(--gb-radius) var(--gb-radius);
152
+ }
153
+ .gb-demo-files-tabs {
154
+ display: flex;
155
+ flex-wrap: wrap;
156
+ align-items: center;
157
+ gap: 2px;
158
+ padding: 6px 8px 0;
159
+ border-bottom: 1px solid var(--gb-border);
160
+ background: var(--gb-muted);
161
+ }
162
+ .gb-demo-file-tab {
163
+ display: inline-flex;
164
+ align-items: center;
165
+ gap: 4px;
166
+ padding: 5px 10px;
167
+ border-radius: 8px 8px 0 0;
168
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
169
+ font-size: 12px;
170
+ color: var(--gb-muted-foreground);
171
+ cursor: pointer;
172
+ }
173
+ .gb-demo-file-tab-active { background: var(--gb-panel); color: var(--gb-text); }
174
+ .gb-demo-file-path { font: inherit; color: inherit; background: transparent; border: none; outline: none; }
175
+ .gb-demo-file-entry {
176
+ padding: 0 5px;
177
+ border-radius: 4px;
178
+ background: var(--gb-accent);
179
+ font-size: 10px;
180
+ text-transform: uppercase;
181
+ letter-spacing: 0.04em;
182
+ }
183
+ .gb-demo-file-body { display: flex; flex-direction: column; }
184
+ .gb-demo-file-meta {
185
+ display: flex;
186
+ align-items: center;
187
+ gap: 12px;
188
+ padding: 4px 12px;
189
+ font-size: 12px;
190
+ color: var(--gb-muted-foreground);
191
+ }
192
+ .gb-demo-file-lang { width: 6rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
193
+ .gb-demo-file-entry-toggle { display: inline-flex; align-items: center; gap: 4px; }
194
+ .gb-demo-file-code {
195
+ display: block;
196
+ width: 100%;
197
+ box-sizing: border-box;
198
+ padding: 8px 12px 12px;
199
+ border: 0;
200
+ outline: none;
201
+ resize: vertical;
202
+ background: transparent;
203
+ color: var(--gb-code-fg);
204
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
205
+ font-size: 12.5px;
206
+ line-height: 1.55;
207
+ tab-size: 2;
208
+ }
209
+ .gb-demo-files-empty { padding: 10px 12px; font-size: 12.5px; color: var(--gb-muted-foreground); }
210
+
91
211
  .gb-source-file-editor {
92
212
  border: 1px solid var(--gb-border);
93
213
  border-radius: var(--gb-radius);
@@ -369,6 +489,84 @@
369
489
  .gb-tabs-content .gb-tab > :first-child { margin-top: 0; }
370
490
  .gb-tabs-content .gb-tab > :last-child { margin-bottom: 0; }
371
491
 
492
+ /* ── Titled tabs (mirrors .docs-tabs-section): heading + pill switch on one row ── */
493
+ .gb-tabs-section {
494
+ border: 0;
495
+ border-radius: 0;
496
+ overflow: visible;
497
+ margin: 1.6em 0 0.9em;
498
+ }
499
+ .gb-tabs-section-head {
500
+ display: flex;
501
+ flex-wrap: wrap;
502
+ align-items: center;
503
+ gap: 12px;
504
+ margin-bottom: 12px;
505
+ }
506
+ .gb-tabs-section-title {
507
+ flex: 1;
508
+ min-width: 10rem;
509
+ margin: 0;
510
+ padding: 0;
511
+ border: 0;
512
+ }
513
+ .gb-tabs-section-input { width: 100%; font-weight: inherit; font-size: inherit; letter-spacing: inherit; }
514
+ .gb-tabs-level {
515
+ font-size: 12px;
516
+ color: var(--gb-muted-foreground);
517
+ background: transparent;
518
+ border: 1px solid var(--gb-border);
519
+ border-radius: 6px;
520
+ }
521
+ .gb-tabs-pills {
522
+ display: inline-flex;
523
+ align-items: center;
524
+ gap: 2px;
525
+ padding: 3px;
526
+ border-radius: 999px;
527
+ background: var(--gb-muted);
528
+ }
529
+ .gb-tabs-pills .gb-tabs-tab { padding: 4px 12px; border-radius: 999px; font-size: 13px; }
530
+ .gb-tabs-pills .gb-tabs-tab-active { background: var(--gb-bg); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); }
531
+ .gb-tabs-section > .gb-tabs-content { padding: 0; }
532
+
533
+ /* ── Command box: docstream's own box, with the editable npm command + overrides below ── */
534
+ .gb-command { margin: 0.85em 0; }
535
+ .gb-command-preview .docs-article { margin: 0; padding: 0; max-width: none; }
536
+ .gb-command-preview .docs-command { margin: 0; }
537
+ .gb-command-fields {
538
+ display: grid;
539
+ grid-template-columns: repeat(3, minmax(0, 1fr));
540
+ gap: 6px 10px;
541
+ margin-top: 6px;
542
+ padding: 8px 10px;
543
+ border: 1px dashed var(--gb-border);
544
+ border-radius: 10px;
545
+ font-size: 12.5px;
546
+ }
547
+ .gb-command-field { display: flex; align-items: baseline; gap: 6px; min-width: 0; }
548
+ .gb-command-field > span {
549
+ flex-shrink: 0;
550
+ width: 2.8rem;
551
+ color: var(--gb-muted-foreground);
552
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
553
+ }
554
+ .gb-command-field-npm { grid-column: 1 / -1; }
555
+ .gb-command-input,
556
+ .gb-command-override {
557
+ flex: 1;
558
+ min-width: 0;
559
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
560
+ font-size: 12.5px;
561
+ }
562
+ .gb-command-input {
563
+ resize: vertical;
564
+ background: transparent;
565
+ border: none;
566
+ outline: none;
567
+ color: inherit;
568
+ }
569
+
372
570
  /* ── Expandable (mirrors .docs-expandable) ──────────────── */
373
571
  .gb-expandable {
374
572
  border: 1px solid var(--gb-border);